【第2216期】CORS 完全手册之为什么会发生CORS 错误?
前言
今日前端早读课文章由@huli授权分享。
正文从这开始~~
三年前的时候写了一篇文章:轻松理解AJAX与跨来源请求,提到了串接API、AJAX、same-origin policy、JSONP以及CORS,当时把自己想讲的都放进去了,但现在回头看,好像有很多满重要的部分没有提到。
三年后,再次挑战这个主题,并且试着表达地更完整。
会想写这个系列是因为在程式相关的讨论区上,CORS 是发问频率很高的主题,无论是前端或是后端都有可能来问相关的问题。
所以我就想说:「好,那我来写一个系列好了,我要试着把这个主题写到每个碰到CORS 问题的人都会来看这个系列,而且看完以后就知道该怎么解决问题」,这算是我对这篇文章的目标,如果文章的品质没办法达成这个目标,我会持续改进。
会从same-origin policy 开始讲起,接着讲到为什么跨来源存取资源会有错误,再来会讲如何错误地以及正确地解决CORS 相关的问题,而第三篇会详细讲解跨来源请求的详细流程,像是preflight request 之类的东西。
基础的部分看前三篇就够了,接下来会比较深一点。第四篇会带你一起看spec,证明前面几篇不是我在虎烂的,而第五篇则是带大家看看CORB(Cross-Origin Read Blocking)、COEP(Cross-Origin Embedder Policy)或是COOP(Cross-Origin-Opener-Policy)之类的跨来源相关规定,以及相关的安全性问题,最后一篇则是一些比较零散的主题以及心得感想。
身为系列文的第一篇,就是要带大家去思考为什么要有same-origin policy 的存在,为什么跨来源存取资源会错误。如果不知道这个问题的答案,那通常都不是真的理解CORS 到底在规范什么,也很有可能会用一些错误的解法去解这个问题。
在这篇里面,我预设大家已经对跨来源请求以及CORS有一些基本概念了,如果完全没有概念的话,可以先参考一下我以前写过的这篇:轻松理解AJAX与跨来源请求。
在正式开始以前,想先跟大家讲一个小故事,跟整个CORS 有关的一个小故事,反正大家就当一个无厘头故事看就好,等真正理解完整个跨来源请求相关的东西以后,就知道这故事代表什么了。
故事的主角是一个求知若渴,希望获得各种资讯的小资(不是小资女孩向前冲的那个小资),而政府想要监控这些求知若渴的人,试图知道他们到底都去问了哪些资讯,所以把他安置在一个小房间,跟外界的沟通都要透过门口的警卫。
所以小资没办法亲自出去,但是有什么想知道的事情都可以问警卫,警卫都会帮他去问。身为一个求知若渴的人,小资常常问他很多问题,例如说:「速食店的大麦克现在一个多少钱?」、「我的存款剩下多少?」、「我爸妈过得好吗」等等。
针对小资的每一个提问,警卫都会帮他去问到当事人,但不一定会把答案告诉他。政府规定了一个程序,那就是「除非被问的人明确同意,不然不能把答案告诉小资」,所以警卫会先问完问题拿到答案,再问说:「请问你愿意让小资知道这件事吗?」,有些人愿意,例如说速食店,虽然他根本不认识小资,但毕竟这类资讯告诉谁都可以。但也有些人不愿意,因为根本不知道谁是小资。还有一种状况,警卫连问都不用问,那就是小资的家人。因为小资的家人跟小资血脉相承,系出同源,所以不用问就可以放行。
于是呢,尽管小资的每一个问题都有传达到被问的人那里,却不一定能收到回覆。有一天小资终于受不了这种被囚禁的生活,于是想了几个方法。
第一个方法是把警卫打倒逃出去,没有警卫了他就自由了,想问谁问题就问谁,不用再透过警卫,完全没有任何拘束。
第二个方法是拜托朋友帮忙当暗桩。每当小资有问题时,都跟警卫说:「你去问我朋友,大麦克多少钱」,接着朋友再去问速食店,再把结果跟警卫讲,顺便交代警卫他愿意让小资知道这件事。因为问题都会透过他朋友转传,而朋友每次都会交代警卫这个资讯可以让小资知道,所以小资就不会有之前提到的那个限制了。
第三个方法是让大家都愿意把资讯告诉他,这样就不会被警卫拦截,就能顺利知道问题的答案。
好,故事结束了,虽然我觉得没有到很贴切就是了,不过浮夸的故事总是比较吸引人注意,就先这样吧,接着让我们来进入主题。
从熟悉的错误讯息开始
我相信大家一定都对这个错误讯息不陌生:
request has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
在前端用XMLHttpRequest 或者是用fetch 的时候,应该都有碰过这个错误。在串接后端或是网路上的API 时,就是串不起来,而你也不知道是哪边出了错,甚至连这是前端还是后端要处理的可能都不太知道。
因此,我在这边直接先跟你讲答案:
大部分情形下,CORS 都不是前端的问题,纯前端是解决不了的。
换句话说,碰到这个错误的时候,通常都不是你应该要解决问题,而是后端。大家可以先把这一句话放在心中,等到看完文章的时候,应该就会认同这句话了。
既然CORS 的这个错误是出在「跨来源呼叫API」,那势必就要两件事情要厘清:
什么是跨来源?
为什么不能跨来源呼叫API?
什么是跨来源?
跨来源的英文是cross origin,顾名思义,当你想要从来源A 去拿来源B 的东西,就是跨来源。
而这个来源,其实就是代表着「发送request的来源」,例如说你现在在发送一个request出去,那这个request的origin就是。https://huli.twhttps://huli.tw
而same origin 就代表着来源一样,如果有两个URL A 跟B 的origin 是一样的,我们就说A 跟B 是same origin,也叫做「同源(同个来源)」。
所以跟不同源,因为它们的origin不一样。https://huli.twhttps://google.com
更精确一点地说,你可以把origin当作是:scheme + host + port的组合。scheme就是最前面的那个https或是http之类的东西,host就是huli.tw,而port的话如果没有特别指定,http预设的port就是80,https就是443。
所以呢,
https://huli.tw跟同源,因为scheme + host + port都一样(是path的部分,不是host)https://huli.tw/api/api
https://huli.tw跟不同源,因为scheme不一样http://huli.tw
http://huli.tw跟不同源,因为port不一样http://huli.tw:3000
https://api.huli.tw跟不同源,因为host不一样https://data.huli.tw
https://huli.tw跟不同源,因为host不一样https://api.huli.tw
第五点是大家要特别注意的一点,domain跟subdomain之间也是不同源的,所以api.huli.tw跟huli.tw不同源。有很多人常常会把这个跟cookie搞混,因为api.huli.tw跟huli.tw是可以共用cookie的。
在这边特别强调,cookie比对的规则叫做:Domain Matching ,它是看domain而不是看我们这边所定义的origin,千万不要搞混了。
从以上范例可以得知,其实要达成same origin满困难的,如果只看网址的话,基本上要长得一模一样,只有path跟后面的部分可以不一样,例如说跟他们都是在同一个scheme + host + post底下,origin都会是,因此这两个网址是同源的。https://huli.tw/a/b/c/index.html?a=1https://huli.tw/show/cool/b.htmlhttps://huli.tw
在实务上面,其实也满常会把前端网站本身跟API用不同的网域来表示,例如说huli.tw就是前端网站,api.huli.tw就是后端API,所以实务上也很常碰到跨来源请求的场景。
(顺带一提,想避开跨来源的话会把前后端放在同一个origin下,例如说huli.tw/api就都是后端API,其他路径则是前端网站。)
为什么不能跨来源呼叫API?
理解了同源的定义之后,我们可以来看刚刚的另一个问题,就是:「为什么不能跨来源呼叫API?」。
但其实这个定义有点不清楚,更精确一点的说法是:「为什么不能用XMLHttpRequest 或是fetch(或也可以简单称作AJAX)获取跨来源的资源?」
会特别讲这个更精确的定义,是因为去拿一个「跨来源的资源」其实很常见,例如说,这其实就是跨来源去抓取资源,只是这边我们抓取的目标是图片而已。<imgsrc="https://another-domain.com/bg.png"/>
或者是:,这也是跨来源请求,去抓一个JS档案回来并且执行。<scriptsrc="https://another-domain.com/script.js"/>
但以上两种状况你有碰到过问题吗?基本上应该都没有,而且你已经用得很习惯了,完全没有想到可能会出问题。
那为什么变成AJAX,变成用XMLHttpRequest 或是fetch 的时候就不同了?为什么这时候跨来源的请求就会被挡住?(这边的说法其实不太精确,之后会详细解释)
要理解这个问题,其实你要反过来想。因为你已经知道「结果」就是会被挡住,既然结果是这样,那一定有它的原因,可是原因是什么呢?这有点像是反证法一样,你想要证明一个东西A,你就先假设A 是错的,然后找出反例发现矛盾,就能证明A 是对的。
要思考这种技术相关问题时也可以采取类似的策略,你先假设「挡住跨来源请求」是错的,是没有意义的,再来如果你发现矛盾,发现其实是必要的,你就知道为什么要挡住跨来源请求了。
因此,可以思考底下这个问题:
如果跨来源请求不会被挡住,会发生什么事?
那我就可以自由自在串API,不用在那边google 找CORS 的解法了!听起来好像没什么问题,凭什么img 跟script 标签都可以,但AJAX 却不行呢?
如果跨来源的AJAX不会被挡的话,那我就可以在我的网域的网页(假设是),用AJAX去拿的资料对吧?https://huli.tw/index.htmlhttps://google.com
看起来好像没什么问题,只是拿Google 首页的HTML 而已,没什么大不了。
但如果今天我恰好知道你们公司有一个「内部」的公开网站,网址叫做,这是外部连不进去的,只有公司员工的电脑可以连的到,然后我在我的网页写一段AJAX去拿它的资料,是不是就可以拿得到网站内容?那我拿到以后是不是就可以传回我的server?http://internal.good-company.com
这样就有了安全性的问题,因为攻击者可以拿到一些机密资料。
目标打开恶意网站
恶意网站用AJAX 抓取内部机密网站的资料
拿到资料
回传给攻击者的server
你可能会问说:「可是要用这招,攻击者也要知道你内部网站的网址是什么,太难了吧!」
如果你觉得这样太难,那我换个例子。
我请问你一个问题,你平常在开发的时候,是不是都是在自己电脑开一个server起来,网址有可能是或是之类的?以现代前端开发来说,这再常见不过了。http://localhost:3000http://localhost:5566
如果浏览器没有挡跨来源的API,那我就可以写一段这样的程式码:
//发出request得到资料
function sendRequest ( url, callback ) { const request = newXMLHttpRequest(); request.open( 'GET', url, true); request.onload = function(
) {
callback( this.response);
}
request.send();
}
//尝试针对每一个port拿资料,拿到就送回去我的server
for( let port = 80; port < 10000; port++) {
sendRequest( ' http :// localhost :'+ port, data => { / /把资料送回我的server }) }
如此一来,只要你有跑在localhost 的server,我就可以拿到你的内容,进而得知你在开发的东西。在工作上,这有可能就是公司机密了,或是攻击者可以藉由分析这些网站找出漏洞,然后用类似的方法打进来。
再者,如果你觉得以上两招都不可行,在这边我们再多一个假设。除了假设跨来源请求不会被挡以外,也假设「跨来源请求会自动附上cookie」。
所以如果我发一个request到,就可以看到你的聊天讯息,发request到,就可以看到你的私人信件。https://www.facebook.com/messages/thttps://mail.google.com/mail/u/0/
讲到这边,你应该可以理解为什么要挡住跨来源的AJAX 了,说穿了就是三个字:
安全性
在浏览器上,如果你想拿到一个网站的完整内容(可以完整读取),基本上就只能透过XMLHttpRequest 或是fetch。若是这些跨来源的AJAX 没有限制的话,你就可以透过使用者的浏览器,拿到「任意网站」的内容,包含了各种可能有敏感资讯的网站。
因此浏览器会挡跨来源的AJAX 是十分合理的一件事,就是为了安全性。
这时候有些人可能会有个疑问:「那为什么图片、CSS 或是script 不挡?」
因为这些比较像是「网页资源的一部分」,例如说我想要用别人的图片,我就用来引入,想要用CSS就用,这些标签可以拿到的资源是有限制的。再者,这些取得回来的资源,我没办法用程式去读取它,这很重要。
我载入图片之后它就真的只是张图片,只有浏览器知道图片的内容,我不会知道,我也没有办法用程式去读取它。既然没办法用程式去读取它,那我也没办法把拿到的结果传到其他地方,就比较不会有资料外泄的问题。
想要正确认识跨来源请求,第一步就是认识「为什么浏览器要把这些挡住」,而第二步,就是对于「怎么个挡法」有正确的认知。底下我准备了两题小测验,大家可以试着回答看看。
随堂小测验
第一题
小明正负责写一个专案,网址是:。这网站会需要用到公司其他网站的某个档案,里面是一些使用者资料,网址是:。小明直接点开这个网址,发现用浏览器可以看到档案的内容,于是就说:https://best-landing-page.twhttps://lidemy.com/users.json
既然我用浏览器可以看得到内容,就表示浏览器打得开,那用AJAX 的时候也一定可以拿得到资料!我们来用AJAX 拿资料吧!
请问小明的说法是正确的吗?如果错误,请指出错误的地方。
第二题 小明正在做的专案需要串接API,而公司内部有一个API是拿来删除文章的,只要把文章id用POST以application/x-www-form-urlencoded的content type带过去即可删除。
举例来说:并带上id=13,就会删除id是13的文章(后端没有做任何权限检查)。POST https://lidemy.com/deletePost
公司前后端的网域是不同的,而且后端并没有加上CORS 的header,因此小明认为前端用AJAX 会受到同源政策的限制,request 根本发不出去。
而实际上呼叫以后,果然console 也出现:「request has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource」 的错误。
所以小明认为前端没办法利用AJAX 呼叫这个API 删除文章,文章是删不掉的。
请问小明的说法是正确的吗?如果错误,请指出错误的地方。
你的跨来源AJAX 是怎么被挡掉的?
上面这两题都是观念题,只要观念正确就可以轻松回答。
而新手,尤其是只碰过浏览器上面的JS 的新手通常观念都不太正确(这很正常),而最容易有的错误观念,就是对于same-origin policy 或者是「跨来源请求」的错误认知。
首先,第一个重要观念是:「你是在浏览器上面写程式」。
这是什么意思?意思就是你在写JavaScript 时的诸多限制,都是浏览器限制你,而不是程式语言本身限制你。那些你没办法做到的事,都是被浏览器挡住了。
JavaScript是一个程式语言,所以像var、if else、for、function等等,这些都是JavaScript的一部分。但JavaScript需要有地方执行,而这个地方就叫做执行环境(runtime),大家最常用的就是:浏览器。
所以你的JavaScript是在浏览器上执行的,而这个执行环境会提供给你一些东西使用,例如说DOM(document)、console.log、setTimeout、XMLHttpRequest或是fetch,这些其实都不是JavsScript(或是更精确地说,ECMAScript )的一部分。这些是浏览器给我们使用的,所以我们只有在浏览器上面执行JavaScript时才用得到。
因此你可能有过类似的经验,想说为什么一样的code搬到Node.js去就没办法执行。现在你知道了,那是因为Node.js并没有提供这些东西,例如说fetch,你没办法直接在Node.js里面使用它(如果可以,那就代表你有用其它library或是polyfill)。
相反过来也是,你把JavaScript用Node.js执行时,你可以用process或是fs,但你在浏览器上面就没办法。不同的执行环境会提供不同的东西,你要很清楚现在是在哪个执行环境。
而有时候,不同的执行环境也会提供相同的东西,例如说console.log跟setTimeout,在浏览器以及Node.js都有。但尽管他们看起来一样,内部实作却是完全不同,表现方法也可能不同。举例来说,浏览器的console.log会输出在devtool的console,而Node.js则是会输出在你的terminal上面。而两者的setTimeout实作也不一样,所以细节可能会有差别。
回到主题,我们在浏览器上想要对一个跨来源的资源做AJAX,然后被挡住了。被谁挡住?浏览器。
换句话说,如果没有浏览器,如果我今天不是在浏览器上面执行程式,那就根本没有什么same-origin policy,也不用管什么CORS。
举例来说,你今天去当兵,早上起床要折豆腐被,中午吃饭进餐厅要喊亲爱精诚,看到长官要问好,讲话开头要加报告两个字。为什么?因为军中是那样规定的。
可是如果你今天退伍了,不在军营里面,也不是阿兵哥了,你就自由了,就再也不用做上面那些事了。浏览器在这边就像是军营,它是一个限制器,有着诸多的规则,一旦脱离它,就什么规则都没有了。
如果你有听懂我在讲什么,大概就知道为什么proxy 一定可以解决CORS 的问题,因为它是透过后端自己去拿资料,而不是透过浏览器(这之后会再详细讲)。
而浏览器本身在开网页的时候,也是根本没有什么same-origin policy 的规则,你想开什么网页就开什么,不会阻止你。
所以随堂测验的第一题,用浏览器打得开那个JSON 档案,这根本不算什么,因为一定打得开,这跟CORS 一点关系都没有。用浏览器浏览网站,跟用AJAX 拿资料是完全不同的两件事。
所以第一题的解答是:「小明的说法错误,用浏览器能打开档案不代表什么,跟CORS 无关。是不是能够跨来源使用AJAX,要看response 的header」。
解决了第一题之后,来看第二题,大意就是小明发了一个request 之后收到CORS 错误,于是就说这request 被挡掉了。
第二题在考的观念是:
跨来源请求被浏览器挡住,实际上到底是什么意思?是怎么被挡掉的?
会有这一题,是因为有很多人认为:「跨来源请求挡住的是request」,因此在小明的例子中,request 被浏览器挡住,没办法抵达server 端,所以资料删不掉。
但这个说法其实想一下就知道有问题,你看错误讯息就知道了:
request has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource
浏览器说没有那个header 存在,就代表什么?代表它已经帮你把request 发出去,而且拿到response 了,才会知道没有Access-Control-Allow-Origin 的header 存在。
所以浏览器挡住的不是request,而是response。你的request 已经抵达server 端,server 也回传response 了,只是浏览器不把结果给你而已。
因此第二题的答案是,尽管小明看到这个CORS 的错误,但因为request 其实已经发到server 去了,所以文章有被删掉,只是小明拿不到response 而已。对,文章被删掉了,真的。
最后再补充一个观念,前面有讲说挡CORS 是为了安全性,如果没有挡的话,那攻击者可以利用AJAX 去拿内网的非公开资料,公司机密就外泄了。而这边我又说「脱离浏览器就没有CORS 问题」,那不就代表就算有CORS 挡住,我还是可以自己发request 去同一个网站拿资料吗?难道这样就没有安全性问题吗?
举例来说,我自己用curl 或是Postman 或任何工具,应该就能不被CORS 限制住了不是吗?
会这样想的人忽略了一个特点,这两种有一个根本性的差异。
假设今天我们的目标是某个公司的内网,网址是:http://internal.good-company.com
如果我直接从我电脑上透过curl 发request,我只会得到一个错误,因为一来我不是在那间公司的内网所以没有权限,二来我什至连这个domain 都有可能连不到,因为只有内网可以解析。
而CORS 是:「我写了一个网站,让内网使用者去开这个网站,并且发送request 去拿资料」。这两者最大的区别是「是从谁的电脑造访网站」,前者是我自己,后者则是透过其他人(而且是可以连到内网的人)。
如图所示,上半部是攻击者自己去连那个网址,会连不进去,因为攻击目标在内网里。所以尽管没有same-origin policy,攻击者依然拿不到想要的东西。
而下半部则是攻击者写了一个恶意网站,并且想办法让使用者去造访那个网站,像是标1 的那边,当使用者造访网站之后,就是2 的流程,会用AJAX 发request到攻击目标(internal server),3 拿完资料以后,就是步骤4 回传到攻击者这边。
有了same-origin policy 的保护,步骤4 就不会成立,因为JS 拿不到fetch 完的结果,所以不会知道response 是什么。
总结
这篇主要讲的是为什么浏览器要挡你东西,以及到底是怎么个挡法,也针对几点我觉得初学者最常出错的观念特别讲了一下,帮大家条列式整理重点:
浏览器会挡你的跨来源请求,是因为安全性问题。因为AJAX 你可以直接拿到整个response,所以不挡的话会有问题,但像是img 标签你其实就拿不到response,所以比较没有问题 今天会有same-origin policy 跟CORS,是因为我们「在浏览器上写JS」,所以受到执行环境的限制。如果我们今天写的是Node.js,就完全没有这些问题,想拿什么就拿什么,不会有人挡我们 在浏览器上面,CORS 限制的其实是「拿不到response」,而不是「发不出request」。所以request 其实已经发出去了,浏览器也拿到response 了,只是它因为安全性考量不给你(这讲法也有一点不太精确,因为有分简单请求跟非简单请求,这个在第三篇会提到)。
关于本文 作者:@huli 原文:https://blog.huli.tw/2021/02/19/cors-guide-1/
为你推荐
【第2170期】在Web应用中减少CORS预检时间的4种方法
欢迎自荐投稿,前端早读课等你来